11.1 提交和显示博客文章

1. 在app/models.py中定义文章模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# ...
class Post(db.Model):
__tablename__ = 'posts'
id = db.Column(db.Integer, primary_key=True)
body = db.Column(db.Text)
body_html = db.Column(db.Text)
timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow())
author_id = db.Column(db.Integer, db.ForeignKey('users.id'))
class User(UserMixin, db.Model):
# ...
posts = db.relationship('Post', backref='author', lazy='dynamic')

说明

  • 为了使文章支持Markdown,用body字段存储markdown源文本,用body_html字段存储markdown源文本转换成的HTML文本。(具体如何将markdown转换成HTML,可见第10步.)

2. 在app/main/forms.py中定义文章表单:

1
2
3
4
5
6
from flask_pagedown import PageDownField
# ...
class PostForm(FlaskForm):
body = PageDownField("What's your mind?", validators=[DataRequired()])
submit = SubmitField('Submit')

3. 在app/main/views.py中定义处理博客文章的主页路由:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from app.models import Post
# ...
@main.route('/', methods=['GET', 'POST'])
def index():
form = PostForm()
if current_user.can(Permission.WRITE_ARTICLES) and form.validate_on_submit():
post = Post(body=form.body.data,
author=current_user._get_current_object()) # 赋予当前用户对象
db.session.add(post)
db.session.commit() # 当设置了请求结束后自动提交数据库变化时,该行可省略
return redirect(url_for('main.index'))
page = request.args.get('page', 1, type=int)
pagination = Post.query.order_by(Post.timestamp.desc()).paginate(
page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'],
error_out=False)
posts = pagination.items # 获取当前分页对象的所有记录
return render_template('index.html', form=form, posts=posts, pagination=pagination)
  • current_user是Flask-Login提供的,其表现类似用户对象,但实际上是一个轻度包装而由包含真正用户对象的对象。数据库需要获取真正的用户对象,所以需调用_get_current_objet()方法获取。
  • 客户端通过URL请求的页码数通过requset.args.get()方法获取,request.args.get('pag', 1, type=int)通过关键字'page'获取页码,如果没有指定页码,则默认请求第1页,type=int保证参数无法转换成整数时,返回默认值(即第一页)。
  • paginate()方法返回的是一个Pagination类对象(分页对象)。该方法第一个参数(必需要有)为页码,可选参数per_page用来指定每一页显示的记录数,如果没有指定,默认显示20个记录;另一个可选参数error_out,当其设为True时(默认值),如果请求的页码超出范围,则返回404错误;如果设为False,则返回一个空列表。常用的分页对象属性如表11-1。

表11-1 Flask-SQLAlchemy分页对象的属性

属性 说明
items 当前页面中的记录
query 分页的源查询
page 当前页码
prev_num 上一页的页码
next_num 下一页的页码
has_next 如果有下一页,则返回True
has_prev 如果有上一页,则返回True
pages 查询得到的总页数
per_page 每页显示的记录数量
total 查询返回的记录总数

在分页对象上还可以调用一些方法,如表11-2

表11-2 在Flask-SQLAlchemy对象上可调用的方法

方法 说明
iter_pages(left_edge=2, left_current=2, right_current=5, right_edge=2) 一个迭代器,返回一个在分页导航中显示的页数列表。这个列表的最左边显示left_edge个页码,当前页的左边显示left_current个页码,当前页的右边显示right_current个页码,最右边显示right_edge个页码。如按当前默认配置,在一个100页的列表中,当前页为第50页,则会返回一下页数:1、2、None、48、49、50、51、52、53、54、55、None、99、100。None表示页数之间的间隔。
prev() 上一页的分页对象
next() 下一页的分页对象
  • 这样,主页中就会显示特定数量的文章,如果想看第2页中的文章,可在URL后加上查询字符串?page=2,那么视图函数就会通过request.args.get()方法获取'page'页码加以处理。

4. 在app/templates/_posts.html中定义用于显示博客文章的局部模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<ul class="posts">
{% for post in posts %}
<li class="post">
<div class="post-thumbnail">
<a href="{{ url_for('main.user', username=post.author.username) }}">
<img class="img-rounded profile-thumbnail" src="{{ post.author.gravatar(size=40) }}">
</a>
</div>
<div class="post-content">
<div class="post-date">{{ moment(post.timestamp).fromNow() }}</div>
<div class="post-author"><a href="{{ url_for('main.user', username=post.author.username) }}">{{ post.author.username }}</a></div>
<div class="post-body">
{% if post.body_html %}
{{ post.body_html | safe }}
{% else %}
{{ post.body }}
{% endif %}
</div>
<div class="post-footer">
{% if current_user == post.author %}
<a href="{{url_for('main.edit', id=post.id) }}">
<span class="label label-primary">Edit</span>
</a>
{% elif current_user.is_administrator() %}
<a href="{{ url_for('main.edit', id=post.id) }}">
<span class="label label-danger">Edit [Admin]</span>
</a>
{% endif %}
<a href="{{ url_for('main.post', id=post.id) }}">
<span class="label label-default">Permalink</span>
</a>
</div>
</div>
</li>
{% endfor %}
</ul>
  • 因为在index.html模板中以及第7步的user.html模板中都需要显示博客文章,为了避免代码重写,将用于显示博客文章的模板分离出来,再在需要的时候加以引用(通过include()指令引用,如include '_post.html')。为了区分独立模板和局部模板,局部模板一般在模板名加下划线_
  • if post.body_html解释见第11步。

5. 在app/templates/_macros.html中定义分页导航条模板宏(显示上一页、1、2、3…下一页):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
{% macro pagination_widget(pagination, endpoint) %}
<ul class="pagination">
<li{% if not pagination.has_prev %} class="disabled"{% endif %}>
<a href="{% if pagination.has_prev %}{{ url_for(endpoint, page=pagination.prev_num, **kwargs) }}{% else %}#{% endif %}">
&laquo;
</a>
</li>
{% for p in pagination.iter_pages() %}
{% if p %}
{% if p == pagination.page %}
<li class="active">
<a href="{{ url_for(endpoint, page=p, **kwargs) }}">{{ p }}</a>
</li>
{% else %}
<li>
<a href="{{ url_for(endpoint, page=p, **kwargs) }}">{{ p }}</a>
</li>
{% endif %}
{% else %}
<li class="disable"><a href="#">&hellip;</a></li>
{% endif %}
{% endfor %}
<li{% if not pagination.has_next %} class="disabled"{% endif %}>
<a href="{% if pagination.has_next %}{{ url_for(endpoint, page=pagination.next_num, **kwargs) }}{% else %}#{% endif %}">
&raquo;
</a>
</li>
</ul>
{% endmacro %}
  • pagination_widget(pagination, endpoint)分别接受分页对象路由端点名作为参数。
  • 宏参数中不用加入**kwargs,分页宏会把接受到的所有关键字参数传给url_for()。这种方式也可以用在路由中,例如包含一个动态部分的资料页。
  • 分页对象的iter_pages()方法返回一个页数列表。
  • 分页链接通过url_for()生成。

6. 在app/templates/index.html中引用_posts.html_macros.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
{% import "_macros.html" as macros %}
{% block title %}Flasky{% endblock %}
{% block page_content %}
<div class="page-header">
<h1>Hello, {% if current_user.is_authenticated %}{{ current_user.username }}{% else %}Stranger{% endif %}!</h1>
</div>
<div>
{% if current_user.can(Permission.WRITE_ARTICLES) %}
{{ wtf.quick_form(form) }}
{% endif %}
</div>
{% include '_posts.html' %}
{% if pagination %}
<div class="pagination">
{{ macros.pagination_widget(pagination, 'main.index') }}
</div>
{% endif %}
{% endblock %}
{% block scripts %}
{{ super() }}
{{ pagedown.include_pagedown() }}
{% endblock %}

7. 在app/main/views.py中定义在个人资料页面中显示该用户所写文章的路由:

1
2
3
4
5
6
7
8
9
10
11
# ...
@main.route('/user/<username>')
def user(username):
user = User.query.filter_by(username=username).first_or_404()
page = request.args.get('page', 1, type=int)
pagination = user.posts.order_by(Post.timestamp.desc()).paginate(
page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'],
error_out=False)
posts = pagination.items
return render_template('user.html', user=user, posts=posts, pagination=pagination)
  • 用户发布的文章通过User.posts关系获取。

11.4 使用Markdown和Flask-PageDown支持富文本文章

实现这个功能需要用到以下一些包:

  • PageDown:使用javaScript实现客户端Markdown到HTML的转换程序。
  • Flask-PageDown:为Flask包装的PageDown,把PageDown集成到Flask—WTF表单中。
  • Markdown:使用Python实现的服务器端Markdown到HTML的转换程序。
  • Bleach:使用Python实现的HTML清理器。

8. 在app/__init__.py中初始化Flask-PageDown:

1
2
3
4
5
6
7
8
9
from flask_pagedown import PageDown
# ...
pagedown = PageDown()
def create_app(config_name):
# ...
pagedown = pagedown.init_app(app)
# ...

9. 在app/templates/index.html中添加Flask-PageDown模板声明:

1
2
3
4
{% block scripts %}
{{ super() }}
{{ pagedown.include_pagedown() }}
{% endblock %}

10. 在app/models.py中在Post模型中处理Markdown文本:

提交表单后,POST请求只会发送纯Markdown文本,页面中显示的HTML预览会被丢掉
安全起见,我们(1)只提交Markdown源文件,(2)然后在服务器上使用Markdown将其转换为HTML文本,(3)得到HTML文本后,在使用Bleach进行清理,确保其中只包含几个允许使用的HTML标签,(4)将清理后得到的HTML文本存储到body_html字段中以提高效率(不用每次请求时都转换)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from markdown import markdown
import bleach
# ...
class Post(db.Model):
# ...
body_html = db.Column(db.Text)
# ...
@staticmethod
def on_changed_body(target, value, oldvalue, initiator):
allowed_tags = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code',
'em', 'i', 'li', 'ol', 'pre', 'strong', 'ul',
'h1', 'h2', 'h3','p']
target.body_html = bleach.linkify(bleach.clean(
markdown(value, output_format='html'),
tags=allowed_tags, strip=True))
db.event.listen(Post.body, 'set', Post.on_changed_body)
  • on_changed_body函数注册在body字段上,是SQLAlchemy'set'事件的监听程序,这意味着只要这个类实例的body字段设了新值,该函数就会被调用。
  • 真正的Markdown转换成HTMl分三步完成:(1)markdown()函数把Markdown文本转换成HTML文本。(2)将得到的HTMl文本和允许使用的HTML标签列表传给clean()函数,删除所有不再白名单中的HTML标签。(3)将上一步得到的HTML文本传给linkify()函数,把纯文本中的URL链接转换成适当的<a>链接(这一步是很必要的,因为Markdown规范没有为自动生成链接提供官方支持)。

11. 在app/templates/_posts.html中判断是否使用文章的HTNL格式:

1
2
3
4
5
6
7
8
# ...
<div class="post-body">
{% if post.body_html %}
{{ post.body_html | sage }}
{% else %}
{{ post.body }}
{% endif %}
</div>
  • 渲染HTNL格式时使用| safe后缀,作用时告诉Jinja2不要转移HTML元素(默认情况下,出于安全考虑,Jinja2会转移所有模板变量)。

12. 在app/main/views.py中定义文章页面路由:

1
2
3
4
5
6
# ...
@main.route('/post/<int:id>')
def post(id):
post = Post.query.get_or_404(id)
return render_template('post.html', post=[post])

注意post.html模板中也通过引用_psots.html显示博客文章,所以传参时要传一个列表(可迭代对象),因为_psots.html中通过for循环获取每一篇文章。

13. 在app/templates/_posts.html中添加文章链接:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<ul class='posts">
{% for post in posts %}
<li class="psot">
...
<div class="post-content">
....
<div class="post-footer">
<a href="{{ url_for('main.post', id=post.id) }}">
<span class="label label-default">Permalink</span>
</a>
</div>
</div>
</li>
{% endfor %}
</ul>

14. 在app/templates/post.html中引用_posts.html

1
2
3
4
5
6
7
8
{% extends "base.html" %}
{% import "_macros.html" as macros %}
{% block title %}Flasky - Post{% endblock %}
{% block page_content %}
{% include "_posts.html" %}
{% endblock %}

11.6 博客文章编辑页面

15. 在app/main/views.py中定义编辑文章的路由:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# ...
@main.route('/edit/<int:id>', methods=['GET', 'POST'])
@login_required
def edit(id):
post = Post.query.get_or_404(id)
if current_user != post.author and \
not current_user.can(Permission.ADMINISTER):
abort(403)
form = PostForm()
if form.validate_on_submit():
post.body = form.body.data
db.session.add(post)
flash('The post has been updated.')
return redirect(url_for('main.post', id=post.id))
form.body.data = post.body
return render_template('edit_post.html', form=form)

注意:这个视图函数的作用是只允许博客文章作者编辑(管理员除外,管理员能编辑所有用户文章)。如果用户试图编辑其他用户的文章,视图函数则会返回403错误。这里使用的PostForm表单类和主页中使用的是同一个。

16. 在app/templates/edit_post.html中定义编辑博客文章的模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
{% block title %}Flasky - Edit Post{% endblock %}
{% block page_content %}
<div class="page-header">
<h1>Edit Post</h1>
</div>
<div>
{{ wtf.quick_form(form) }}
</div>
{% endblock %}
{% block scripts %}
{{ super() }}
{{ pagedown.include_pagedown() }}
{% endblock %}

17. 在app/templates/_posts.html中添加一个在文章下面指向编辑文章页面的链接:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<ul class='posts">
{% for post in posts %}
<li class="psot">
...
<div class="post-content">
....
<div class="post-footer">
{% if current_user == post.author %}
<a href="{{url_for('main.edit', id=post.id) }}">
<span class="label label-primary">Edit</span>
</a>
{% elif current_user.is_administrator() %}
<a href="{{ url_for('main.edit', id=post.id) }}">
<span class="label label-danger">Edit [Admin]</span>
</a>
{% endif %}
<a href="{{ url_for('main.post', id=post.id) }}">
<span class="label label-default">Permalink</span>
</a>
</div>
</div>
</li>
{% endfor %}
</ul>